arnaucube
arnaucube/jellyfin-toolsPublic Self-hosted repository
jellyfin-tools/jellyfin-year-review/web/src/routes/[...path]/+page.svelte
jellyfin-year-review/web/src/routes/[...path]/+page.svelteRaw
  1<script>
  2  import { onMount } from 'svelte';
  3  import { t, message, locale } from '../../lib/i18n.js';
  4  import Poster from '../../lib/Poster.svelte';
  5  import PlatformBreakdown from '../../lib/PlatformBreakdown.svelte';
  6  import Chart from '../../lib/Chart.svelte';
  7  import RadarChart from '../../lib/RadarChart.svelte';
  8  import ViewingTimeline from '../../lib/ViewingTimeline.svelte';
  9  import RankingPosition from '../../lib/RankingPosition.svelte';
 10  import Confetti from '../../lib/Confetti.svelte';
 11
 12  let user = $state(null), years = $state([]), year = $state(''), review = $state(null);
 13  let loading = $state(true), signingIn = $state(false), error = $state(''), notice = $state('');
 14  let username = $state(''), password = $state(''), slide = $state(0);
 15  let swipeDirection = $state(0);
 16  let touch = null, request = 0;
 17  let authMode = $state('jellyfin');
 18  const chapters = [
 19    { id: 0, name: t("Your viewing recap") },
 20    { id: 1, name: t("Your watch time"), availability: 'exactWatchTime' },
 21    { id: 2, name: t("Movies watched") },
 22    { id: 3, name: t("TV shows watched") },
 23    { id: 4, name: t("Unfinished movies"), availability: 'completion' },
 24    { id: 14, name: t("Shows you've watched are from platforms...") },
 25    { id: 5, name: t("Your top genres") },
 26    { id: 6, name: t("Viewing by hour") },
 27    { id: 7, name: t("Viewing by weekday") },
 28    { id: 8, name: t("Viewing by month") },
 29    { id: 9, name: t("Your devices and apps") },
 30    { id: 10, name: t("Your longest viewing session"), availability: 'marathon' },
 31    { id: 11, name: t("Your viewing profile"), availability: 'personality' },
 32    { id: 12, name: t("It's not a competition... but this is your watch-time ranking"), availability: 'ranking' },
 33    { id: 13, name: t("Your year in summary") }
 34  ];
 35  // Navigation and chapter numbering follow only the available cards for this year.
 36  let cards = $derived(chapters.filter(chapter => !chapter.availability || review?.availability?.[chapter.availability]?.available !== false));
 37  let card = $derived(cards[slide]?.id);
 38  const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'].map(value => t(value));
 39  const weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map(value => t(value));
 40  const count = (n) => new Intl.NumberFormat(locale).format(n);
 41  const hours = (n) => new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(n / 3600);
 42  const duration = (n) => t("{hours} h", { hours: hours(n) });
 43  const interactive = (target) => target instanceof Element && !!target.closest('button, input, select, a, textarea');
 44
 45  async function api(path, options = {}) {
 46    let res, body;
 47    try {
 48      res = await fetch(path, { credentials: 'same-origin', ...options });
 49      body = await res.json();
 50    } catch {
 51      throw new Error(t("The request could not be completed."));
 52    }
 53    if (!res.ok) {
 54      if (res.status === 401 && path !== '/api/login') { user = null; review = null; }
 55      throw Object.assign(new Error(message(body.error) || t("The request could not be completed.")), { status: res.status });
 56    }
 57    return body;
 58  }
 59  async function loadReview() {
 60    if (!year) return;
 61    const current = ++request;
 62    loading = true; error = ''; review = null; slide = 0; swipeDirection = 0;
 63    try {
 64      const data = await api(`/api/review?year=${encodeURIComponent(year)}`);
 65      if (current !== request) return;
 66      if (data.schemaVersion !== 1) throw new Error(t("This review format is not supported. Ask the operator to update the app."));
 67      review = data;
 68      history.replaceState(null, '', `/review/${year}`);
 69    } catch (e) { if (current === request) error = e.message; }
 70    finally { if (current === request) loading = false; }
 71  }
 72  async function bootstrap() {
 73    loading = true; error = '';
 74    try {
 75      const config = await api('/api/config'); authMode = config.authMode;
 76      const session = await api('/api/session'); user = session.user; years = session.years;
 77      const pathYear = location.pathname.split('/').filter(Boolean)[1];
 78      year = String(years.includes(Number(pathYear)) ? Number(pathYear) : years.at(-1) || '');
 79      if (year) await loadReview();
 80    } catch (e) { if (user || e.status !== 401) error = e.message; }
 81    finally { loading = false; }
 82  }
 83  async function login(event) {
 84    event.preventDefault(); signingIn = true; error = ''; notice = '';
 85    try {
 86      await api('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) });
 87      password = ''; await bootstrap();
 88    } catch (e) { error = e.message; }
 89    finally { password = ''; signingIn = false; }
 90  }
 91  async function logout() {
 92    notice = ''; error = '';
 93    try { await api('/api/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); }
 94    catch (e) { notice = e.message; }
 95    finally { request++; user = null; review = null; years = []; password = ''; history.replaceState(null, '', '/'); }
 96  }
 97  // Route every navigation method through the same direction and boundary rules.
 98  function goTo(index) {
 99    const next = Math.max(0, Math.min(cards.length - 1, index));
100    if (next === slide) return;
101    swipeDirection = next > slide ? 1 : -1;
102    slide = next;
103  }
104  function navigate(delta) { goTo(slide + delta); }
105  function keydown(event) {
106    if (!review || interactive(event.target) || event.altKey || event.ctrlKey || event.metaKey) return;
107    if (event.key === 'ArrowRight') { event.preventDefault(); navigate(1); }
108    if (event.key === 'ArrowLeft') { event.preventDefault(); navigate(-1); }
109    if (event.key === 'Home') { event.preventDefault(); goTo(0); }
110    if (event.key === 'End') { event.preventDefault(); goTo(cards.length - 1); }
111  }
112  function pointerdown(event) { if (event.pointerType === 'touch' && !interactive(event.target)) touch = { x: event.clientX, y: event.clientY }; }
113  function pointerup(event) {
114    if (!touch) return;
115    const dx = event.clientX - touch.x, dy = event.clientY - touch.y; touch = null;
116    if (Math.abs(dx) > 65 && Math.abs(dx) > Math.abs(dy) * 1.5) navigate(dx < 0 ? 1 : -1);
117  }
118  onMount(bootstrap);
119</script>
120
121<svelte:head><title>{t("Jellyfin Year Review")}</title><meta name="description" content={t("Your private Jellyfin year in movies and television.")} /></svelte:head>
122<svelte:window onkeydown={keydown} onpointerup={pointerup} onpointercancel={() => touch = null} />
123
124<div class="app-shell" class:login-only={!user}>
125  {#if user}
126  <header class="topbar">
127    <a class="brand" href="/" aria-label={t("Jellyfin Year Review home")}><span class="brand-icon">{t("J")}</span><span>{t("Jellyfin")} <span class="brand-light">{t("Year Review")}</span><small>{t("A YEAR WORTH REPLAYING")}</small></span></a>
128    {#if user}
129      <div class="account">
130        {#if years.length > 1}<label class="year-picker">{t("Year")}<select aria-label={t("Review year")} bind:value={year} onchange={loadReview}>{#each years as y}<option value={String(y)}>{y}</option>{/each}</select></label>{/if}
131        <button class="quiet" onclick={logout}>{t("Sign out")}</button>
132      </div>
133    {/if}
134  </header>
135  {/if}
136
137  <main>
138    {#if user}
139      {#if notice}<p class="message" role="status">{notice}</p>{/if}
140      {#if error}<div class="message error" role="alert">{error}<button class="quiet" onclick={bootstrap}>{t("Try again")}</button></div>{/if}
141    {/if}
142    {#if !user}
143      <section class="login-layout" aria-label={t("Sign in")}>
144        <form class="login-panel" onsubmit={login}>
145          <h1 style="white-space: pre-line">{t("Your year,\nreplayed.")}</h1><p>{t("Sign in with your existing Jellyfin account.")}</p>
146          {#if notice}<p class="message" role="status">{notice}</p>{/if}
147          {#if error}<div class="message error" role="alert">{error}</div>{/if}
148          <label for="username">{t("Jellyfin username")}</label><input id="username" name="username" autocomplete="username" required maxlength="256" bind:value={username} />
149          <label for="password">{t("Password")}</label><input id="password" name="password" type="password" autocomplete="current-password" bind:value={password} />
150          <button class="primary" type="submit" disabled={loading || signingIn}>{loading ? t("Loading…") : signingIn ? t("Signing in…") : t("Roll the credits")} <span aria-hidden="true">↗</span></button>
151          <small class="login-note">{authMode === 'offline' ? t("Your credentials are checked locally against a private Jellyfin account snapshot. Password changes take effect after the operator refreshes it.") : t("Your credentials are validated by your Jellyfin server. Your review stays private.")}</small>
152        </form>
153      </section>
154    {:else if loading}
155      <section class="state-screen"><span class="loading-orbit" aria-hidden="true"></span><h1>{t("Setting the scene…")}</h1><p role="status">{t("Loading your private review")}</p></section>
156    {:else if !years.length}
157      <section class="state-screen"><p class="eyebrow">{t("NOT QUITE SHOWTIME")}</p><h1>{t("Your story is on its way.")}</h1><p>{t("No review has been generated for your account. Ask your server operator to generate a year.")}</p><button class="primary" onclick={bootstrap}>{t("Check again")}</button></section>
158    {:else if review}
159      <div class="review-meta"><span>{review.year} / {user.name}</span></div>
160      <nav class="progress" aria-label={t("Review chapters")}>{#each cards as chapter, i}<button class:active={slide === i} class:visited={slide > i} aria-label="{i + 1}. {chapter.name}" aria-current={slide === i ? 'step' : undefined} onclick={() => goTo(i)}><span></span></button>{/each}</nav>
161      <section class="story tone-{slide % 4}" aria-roledescription={t("carousel")} aria-label={t("Your yearly review")} onpointerdown={pointerdown}>
162        <div class="story-orbit" aria-hidden="true"></div>
163        <div class="story-content" aria-live="polite" aria-atomic="true">
164        {#key card}
165        <div class="story-body" style:--swipe-from="{swipeDirection * 105}%">
166          <p class="eyebrow">{t("CHAPTER {number} / {name}", { number: String(slide + 1).padStart(2, '0'), name: cards[slide].name.toLocaleUpperCase(locale) })}</p>
167          {#if card === 0}
168            <span class="year-stamp">{review.year}</span><h1>{t("Your viewing recap")}</h1><p class="story-lede">{t(review.records ? "{name}, see the movies, TV shows, and viewing habits that made up your year." : "{name}, no viewing activity was recorded for this year.", { name: user.name })}</p><button class="primary" onclick={() => navigate(1)}>{t("View my recap")} <span aria-hidden="true">→</span></button><span class="giant-play" aria-hidden="true">▶</span>
169          {:else if card === 1}
170            <h2>{t("Your watch time")}</h2><div class="big-stat">{hours(review.seconds)}<span>{t(review.availability.exactWatchTime.available ? "hours watched" : "hours watched · end-attributed estimate")}</span></div>
171            <div class="stat-grid"><div><b>{duration(review.movieSeconds)}</b><span>{t("Movies")}</span></div><div><b>{duration(review.tvSeconds)}</b><span>{t("Television")}</span></div><div><b>{count(review.records)}</b><span>{t("Activity records")}</span></div><div><b>{count(review.uniqueTitles)}</b><span>{t("Unique movies & episodes")}</span></div></div>
172          {:else if card === 2 || card === 3}
173            {@const items = card === 2 ? review.movies : review.shows}
174            <h2>{card === 2 ? t("Movies watched") : t("TV shows watched")}</h2>
175            <p>{t("Everything you watched in {year}, grouped by month.", { year: review.year })}</p>
176            <ViewingTimeline {items} year={review.year} kind={card === 2 ? 'movies' : 'shows'} />
177          {:else if card === 4}
178            <h2>{t("Unfinished movies")}</h2><p class="story-lede">{t("Movies where you watched less than {percent}% of the runtime.", { percent: review.rules.completionPercent })}</p>
179            {#if review.abandoned.length}<ol class="content-list compact">{#each review.abandoned.slice(0, 5) as item}<li><Poster {item} /><div><h3>{item.title}</h3><p>{t("{percent}% of the movie watched", { percent: Math.round(item.completionPercent) })}</p></div></li>{/each}</ol>{:else}<p class="empty-note">{review.availability.completion.available ? t("No unfinished movies found in supported records.") : t("Completion is unavailable without runtime and actual position ranges.")}</p>{/if}
180          {:else if card === 14}
181            <h2>{t("Shows you've watched are from platforms...")}</h2>
182            <p>{t("Distinct TV shows watched, grouped by platform.")}</p>
183            <PlatformBreakdown platforms={review.platforms ?? []} records={review.records} />
184          {:else if card === 5}
185            <h2>{t("Your top genres")}</h2><p>{t("Genres ranked by watch time.")}</p>
186            {#if review.genres.length}
187              {@const values = review.genres.slice(0, 7).map(v => v.seconds / 3600)}
188              {@const labels = review.genres.slice(0, 7).map(v => message(v.name))}
189              <Chart {values} {labels} metric={t("h")} />
190              <RadarChart {values} {labels} metric={t("h")} />
191            {:else}<p class="empty-note">{t("No genre history is available.")}</p>{/if}
192          {:else if card === 6}
193            <h2>{t("Viewing by hour")}</h2><p>{t("Your viewing activity across the hours of the day.")}</p><div class="hour-chart"><Chart values={review.hourly} labels={Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`)} metric={review.distributionMetric === 'watched minutes' ? t("min") : t("records")} /></div>
194          {:else if card === 7}
195            <h2>{t("Viewing by weekday")}</h2><p>{t("Your viewing activity for each day of the week.")}</p><Chart values={[...review.weekdays.slice(1), review.weekdays[0]]} labels={weekdays} metric={review.distributionMetric === 'watched minutes' ? t("min") : t("records")} />
196          {:else if card === 8}
197            <h2>{t("Viewing by month")}</h2><p>{t("Your viewing activity for each month of {year}.", { year: review.year })}</p><Chart values={review.monthly} labels={months} metric={review.distributionMetric === 'watched minutes' ? t("min") : t("records")} />
198          {:else if card === 9}
199            <h2>{t("Your devices and apps")}</h2><p>{t("Your most-used devices and apps, ranked by watch time.")}</p><div class="two-columns"><div><h3>{t("Devices")}</h3><Chart values={review.devices.slice(0, 5).map(v => v.seconds / 3600)} labels={review.devices.slice(0, 5).map(v => message(v.name))} metric={t("h")} /></div><div><h3>{t("Apps & clients")}</h3><Chart values={review.clients.slice(0, 5).map(v => v.seconds / 3600)} labels={review.clients.slice(0, 5).map(v => message(v.name))} metric={t("h")} /></div></div>
200          {:else if card === 10}
201            <h2>{t("Your longest viewing session")}</h2>{#if review.marathon}<div class="big-stat">{hours(review.marathon.watchedSeconds)}<span>{t("hours watched in your longest session")}</span></div><p class="story-lede">{t("{duration} elapsed · {records} records", { duration: duration(review.marathon.elapsedSeconds), records: count(review.marathon.records) })}</p><p>{new Date(review.marathon.start).toLocaleString(locale, { timeZone: review.timezone })}</p>{:else}<p class="empty-note">{message(review.availability.marathon.reason)}</p>{/if}
202          {:else if card === 11}
203            <h2>{t("Your viewing profile")}</h2>{#if review.personality}<span class="personality-symbol" aria-hidden="true">✳</span><h3 class="personality-name">{message(review.personality.name)}</h3><p class="story-lede">{message(review.personality.explanation)}</p>{:else}<p class="empty-note">{message(review.availability.personality.reason)}</p>{/if}
204          {:else if card === 12}
205            <h2 class="ranking-title"><span>{t("It's not a competition...")}</span>{' '}<span class="ranking-subtitle">{t("but this is your watch-time ranking")}</span></h2>
206            {#if review.ranking}
207              <div class="ranking-highlights">
208                <div class="big-stat"><span class="ranking-value">#{review.ranking.rank}</span><span>{t("of {count} eligible viewers", { count: count(review.ranking.cohort) })}</span></div>
209                <div class="big-stat"><span class="ranking-value">{hours(review.seconds)} <small>{t("h")}</small></span><span>{t("Your total hours watched · movies & TV shows")}</span></div>
210              </div>
211              <RankingPosition ranking={review.ranking} />
212              <div class="stat-grid ranking-secondary"><div><b>{Math.round(review.ranking.percentile)}%</b><span>{t("Viewers with less watch time")}</span></div></div>
213            {:else}<p class="empty-note">{message(review.availability.ranking.reason)}</p>{/if}
214          {:else}
215            <p class="year-stamp">{t("SUMMARY / {year}", { year: review.year })}</p><h2>{t("Your year in summary")}</h2><div class="stat-grid">{#if review.availability.exactWatchTime.available !== false}<div><b>{duration(review.seconds)}</b><span>{t("Your watch time")}</span></div>{/if}{#if review.availability.personality.available !== false}<div><b>{message(review.personality?.name) || t("No viewing profile")}</b><span>{t("Your viewing profile")}</span></div>{/if}</div><p class="story-lede">{t("You have reached the end of your viewing recap.")}</p><button class="primary" onclick={() => goTo(0)}>{t("View recap again ↺")}</button>
216          {/if}
217        </div>
218        {/key}
219        </div>
220        <div class="story-bottom"><span>{String(slide + 1).padStart(2, '0')} <span class="muted">/ {cards.length}</span></span><div class="story-controls"><button aria-label={t("Previous chapter")} onclick={() => navigate(-1)} disabled={slide === 0}>←</button><button aria-label={t("Next chapter")} onclick={() => navigate(1)} disabled={slide === cards.length - 1}>→</button></div></div>
221      </section>
222      <details class="source-notes"><summary>{t("Behind your review · source & availability")}</summary><p>{message(review.source)}</p><p>{message(review.coverage)}</p><p>{t("Snapshot: {date} · Format v{version}", { date: new Date(review.generatedAt).toISOString(), version: review.schemaVersion })}</p>{#each review.warnings as warning}<p>{message(warning)}</p>{/each}</details>
223    {:else}<section class="state-screen"><h1>{t("Let’s try that again.")}</h1><button class="primary" onclick={bootstrap}>{t("Reload review")}</button></section>{/if}
224  </main>
225  {#if user}<footer><span>{t("MADE FOR THE LOVE OF THE WATCH.")}</span></footer>{/if}
226</div>
227
228{#if user && !loading && review?.ranking && card === 12}<Confetti />{/if}